缓存穿透
1.1 定义
一个请求,请求的数据在缓存层没有,在MySQL层也没有,这条请求“穿”过去了
1.2 解决方法
- Bloom Filter:在进行请求Redis之前,先由布隆过滤器判断请求的数据是否存在,其认定为不存在的数据一定不存在,认定为存在的数据可能会存在。这样就可以过滤掉大部分无效的请求,减轻缓存和MySQL的压力。
- 缓存null值:经过缓存层和MySQL层查询到数据不存在之后,给数据缓存一个null值到Redis,设置一个短的TTL,之后再来请求该数据,返回null即可。
1.3 两种解决方案对比
| 特性 | 缓存空对象 | 布隆过滤器 |
|---|---|---|
| 开发难度 | 极低(几行代码) | 中等(需处理初始化与同步) |
| 内存消耗 | 较高(取决于无效 Key 数量) | 极低 |
| 数据一致性 | 易于保持(通过 TTL) | 较难维护(删除操作麻烦) |
| 防御效果 | 防御==重复==的无效请求 | 防御海量、==不重复==的无效请求 |
我们日常项目只需要使用缓存空对象的方法即可,因为现代内存(如 Redis 内存)相对廉价,相比之下,维护一套布隆过滤器的复杂度和出错率(例如数据同步失败导致的拦截错误)更让架构师头疼。
1.4 缓存穿透产生的场景
1. 恶意攻击(最常见原因)
这是缓存穿透被反复提及的核心原因。攻击者会利用自动化工具,通过程序脚本伪造海量的、数据库中根本不存在的请求。
- 随机 ID 攻击: 假设你的商品 ID 是自增的(如
1001,1002),攻击者可以发起诸如id=-1、id=999999999或者id=uuid-xxxx-xxxx这种随机字符串的请求。 - 目的: 攻击者的目标不是为了获取数据,而是为了绕过 Redis 直接冲击数据库。如果数据库并发处理能力较弱,瞬间的大量无效请求会导致数据库连接池耗尽,从而引发系统崩溃。
2. 爬虫抓取
一些不规范的爬虫在爬取网站数据时,可能会根据某种规律尝试“盲猜” URL 或接口参数。
- 遍历尝试: 爬虫可能会尝试遍历所有的数字 ID。如果你的业务中存在大量已逻辑删除的数据,或者 ID 序列中间有很大的空洞(例如分布式 ID 产生的空隙),爬虫就会产生大量命中不了数据库的请求。
3. 业务逻辑漏洞或数据下线
有时候,这种请求是由于系统前后的状态不一致导致的:
- 数据大范围下线: 比如某个促销活动结束,或者一批商品被紧急下架。虽然前端页面可能不再显示入口,但如果用户收藏了链接,或者旧版本的 App 缓存了跳转路径,用户依然会发起这些 ID 的请求。
- 输入校验不严: 前端或 API 网关如果没有对参数进行基础的合法性校验(例如 ID 必须是正整数、长度限制等),错误的参数就会透传到后端逻辑中。
1.5 实际代码开发
1.5.1 缓存空对象
缓存空对象的正确做法是缓存一个约定好的字符串,如
""、"null",因为Redis的Value是不能直接缓存Java中的null的代码示例:
缓存
"":java// 1. 从 Redis 查询 String json = stringRedisTemplate.opsForValue().get(key); // 2. 判断是否存在 if (StrUtil.isNotBlank(json)) { // 存在且有真实数据,反序列化返回 return JSONUtil.toBean(json, User.class); } // 3. 关键点:判断命中目标是否是“空对象” (防止缓存穿透) // 如果 json 不为 null,说明它是我们之前存入的 ""(空字符串) if (json != null) { return null; // 直接返回 null,不再查询数据库 } // 4. 数据库查询 User user = getById(id); // 5. 数据库也不存在 if (user == null) { // 【正确做法】:存入空字符串 "",并设置较短的过期时间(例如 2 分钟) stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES); return null; } // 6. 数据库存在,写入缓存 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(user), 30L, TimeUnit.MINUTES); return user;缓存
"NULL":java// 1. 从 Redis 查询 String json = stringRedisTemplate.opsForValue().get(key); // 2. 判断是否存在 if (StrUtil.isNotBlank(json)) { // 3. 关键点:判断命中目标是否是“空对象” (防止缓存穿透) // 这里json不是我们缓存的"NULL"就是真实数据 if ("NULL".equals(json)) { return null; // 直接返回 null,不再查询数据库 } // 存在且有真实数据,反序列化返回 return JSONUtil.toBean(json, User.class); } // 4. 数据库查询 User user = getById(id); // 5. 数据库也不存在 if (user == null) { // 【正确做法】:存入空字符串 "",并设置较短的过期时间(例如 2 分钟) stringRedisTemplate.opsForValue().set(key, "NULL", 2L, TimeUnit.MINUTES); return null; } // 6. 数据库存在,写入缓存 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(user), 30L, TimeUnit.MINUTES); return user;
1.6 其他解决方案
- 增加ID复杂度,避免被猜测ID规律,并进行合理的ID校验,拒绝攻击者或爬虫的访问
- 增加数据基础格式的校验
- 权限校验
- 对热点key限流